feat(storage): Implement ObjectStoreStorage::S3 (Supersedes #2257) - #3165
Sruhvx-jpg wants to merge 12 commits into
Conversation
…oncurrent deletes - Hoist `object_store` 0.13 to workspace dependencies to align with DataFusion. - Support `s3n://` scheme alongside `s3://` and `s3a://` in `parse_s3_url`. - Optimize `delete_stream` with `try_for_each_concurrent` instead of sequential loop. - Add unit tests for `s3n://` URL parsing and FileIO/Storage serialization roundtrips. - Wire crate workspace lints and publish flag.
|
Apologies for any notification noise from the extra PR earlier. Everything has been cleanly unified into this PR :) |
a476be2 to
37180ce
Compare
|
Hey everyone, got all the CI checks passing and green now! Since this is a pretty big PR, just wanted to say that if you guys like the work, I'd really love to stick around and continue making it better—handling any feedback, tuning performance, and helping add other backends like GCS or Azure down the line. Whenever you get some time to check it out, let me know what you think! :) |
|
cc @CTTY @kevinjqliu — CI is completely green on this. Since this directly revives and finishes #2257, whenever you have a moment to take a look, I'd really appreciate your review on the S3 backend implementation! |
laskoviymishka
left a comment
There was a problem hiding this comment.
Really glad to see object_store wired into the Storage framework — the factory, per-bucket cache, and path plumbing are all in good shape, and this is the right base for the follow-up backends. I'd hold it before merge though, since the whole object_store stack is going to build on top of this crate and a few of these are hard to walk back once it's published.
The one that worries me most is build_s3_store silently dropping most of S3Config. When an operator sets s3.sse.type=kms or custom, TryFrom populates the SSE fields but nothing forwards them to the builder, so we'd write data unencrypted even though encryption was explicitly required — a silent security regression versus opendal/Java. AmazonS3Builder has with_sse_kms_encryption/with_ssec_encryption for this, and for the fields object_store genuinely can't express (assume-role, disable-ec2-metadata) I'd return an error rather than drop them silently.
Things I'd like to settle in this PR before the follow-ups build on it:
- Forward the SSE config, and error on the config fields object_store can't express instead of dropping them
- Rework
parse_s3_urlto use the parsedUrlfields instead of slicing the raw string (uppercase scheme + percent-encoded bucket both break today) - Make the
store_cache/configvariant fields private before the first publish - Add a
Dropthat aborts the multipart upload so a dropped writer doesn't orphan parts - Land at least a thin integration test against localstack/MinIO — nothing currently exercises a real read/write
None of it is structural — the design is right. Once those are addressed I'm happy to take another pass and approve.
| Error::new(ErrorKind::DataInvalid, format!("Invalid URL: {path}")).with_source(e) | ||
| })?; | ||
|
|
||
| let scheme = &path[..url.scheme().len()]; |
There was a problem hiding this comment.
This parses with Url but then slices the raw input using lengths taken from the normalized parsed fields, and the two don't always line up.
Two concrete failures: an uppercase scheme like S3://bucket/key gets sliced as &path[..2] = "S3", which falls through the match to the unsupported-scheme error and rejects a valid URL. And url.host_str() is percent-decoded, so s3://my%2Dbucket/key gives bucket_str = "my-bucket" (9 bytes) while the raw span is 11 bytes — the bucket slice at line 67 returns the wrong bytes and prefix_len is off, which can panic on a char boundary.
I'd match on url.scheme() directly (it's already lowercased) and pull bucket/relative from url.host_str() / url.path().trim_start_matches('/'), returning owned Strings instead of slicing the input — that's what the opendal sibling does. wdyt?
There was a problem hiding this comment.
i find using existing code be used again to be fit
| } | ||
|
|
||
| /// Build an `AmazonS3` store from iceberg's `S3Config` for a given bucket. | ||
| pub(crate) fn build_s3_store(config: &S3Config, bucket: &str) -> Result<Arc<dyn ObjectStore>> { |
There was a problem hiding this comment.
This maps 7 of the 16 S3Config fields and silently drops the rest, and the SSE fields are the dangerous ones: when an operator sets s3.sse.type=kms or custom, TryFrom populates the SSE fields on S3Config but nothing here forwards them, so we write data unencrypted even though encryption was explicitly required. AmazonS3Builder exposes with_sse_kms_encryption / with_ssec_encryption, and the opendal sibling maps all three SSE types — I'd mirror that.
The assume-role fields (role_arn, external_id, role_session_name) and disable_ec2_metadata / disable_config_load are also dropped, and object_store has no builder API for those. Silently ignoring them is worse than not supporting them — a role-based config falls through to the credential chain and only fails at first I/O. I'd return a FeatureUnsupported/DataInvalid error listing the unsupported non-default fields rather than dropping them.
Fix the SSE forwarding and error on the fields we can't express, and this one's resolved.
| config: Arc<S3Config>, | ||
| /// Per-bucket store cache. | ||
| #[serde(skip, default)] | ||
| store_cache: Arc<DashMap<String, Arc<dyn ObjectStore>>>, |
There was a problem hiding this comment.
Since this crate is publish = true, these two variant fields become part of the public API the moment it hits crates.io — public-api.txt already records both as pub. store_cache especially is pure implementation detail; exposing Arc<DashMap<...>> as a public field locks the cache structure into semver, so we couldn't later switch to Mutex<HashMap> or add a store abstraction without a breaking change.
I'd wrap the variant data in a struct with private fields and a pub fn new(config) constructor, exposing the config through a getter if callers need it. Better to lock this down before the first publish than after.
|
|
||
| /// Writer that implements `FileWrite` using `object_store` multipart upload. | ||
| struct ObjectStoreWriter { | ||
| writer: Option<WriteMultipart>, |
There was a problem hiding this comment.
WriteMultipart doesn't complete or abort on drop, and there's no Drop impl here, so if an ObjectStoreWriter is dropped without close() — panic unwind, an early ? return, a cancelled future — the uploaded parts are orphaned in the bucket, billed indefinitely and never committed.
I'd add a Drop that best-effort aborts the inner WriteMultipart via take(). Worth flagging that no test will catch this since it only surfaces as leaked S3 state. wdyt?
|
|
||
| /// Convert an `object_store::Error` into an `iceberg::Error`. | ||
| fn from_object_store_error(e: object_store::Error) -> Error { | ||
| Error::new(ErrorKind::Unexpected, "Failure in doing io operation").with_source(e) |
There was a problem hiding this comment.
This collapses every object_store::Error to ErrorKind::Unexpected, so a NotFound coming back from read/metadata/delete is indistinguishable from a network failure without downcasting the source. exists special-cases NotFound itself, but the others don't, and callers rely on ErrorKind::NotFound for control flow like commit-conflict detection and manifest reads.
I'd dispatch on the object_store::Error variant here — NotFound → ErrorKind::NotFound, PermissionDenied → the closest matching kind, else Unexpected — so every caller gets the right kind for free.
| use super::*; | ||
|
|
||
| #[cfg(feature = "object_store-s3")] | ||
| fn make_s3_storage() -> ObjectStoreStorage { |
There was a problem hiding this comment.
The tests here all run against S3Config::default(), and AmazonS3Builder::build() doesn't validate eagerly, so the cache and roundtrip tests pass without ever touching a backend — none of write/read/reader/delete/delete_prefix/delete_stream/metadata is actually exercised. That's false confidence about exactly the paths most likely to break (multipart lifecycle, serial-vs-batch delete, range reads).
The opendal sibling has a localstack-backed test in CI. I'd add a feature/env-gated integration target covering a write+read roundtrip, a range read, and delete_prefix over 10+ objects before the follow-up backends lean on this crate. wdyt?
There was a problem hiding this comment.
am not the best at this, so here i willl take ur and ai assistance
| futures = { workspace = true } | ||
| iceberg = { workspace = true } | ||
| object_store = { workspace = true } | ||
| serde = { workspace = true } |
There was a problem hiding this comment.
serde = { workspace = true } inherits only features = ["rc"], but this crate uses #[derive(Serialize, Deserialize)]. It compiles in-workspace only because typetag/iceberg happen to activate serde/derive through feature unification — a downstream consumer depending on just this crate would hit use of undeclared crate serde_derive.
Since publish = true, I'd declare it explicitly: serde = { workspace = true, features = ["derive"] }.
|
|
||
| async fn delete_prefix(&self, path: &str) -> Result<()> { | ||
| let (store, object_path) = self.get_store_and_path(path)?; | ||
| let prefix = if object_path.as_ref().ends_with('/') { |
There was a problem hiding this comment.
ObjectStorePath::from always strips trailing slashes, so ends_with('/') is always false and the else branch just re-appends-then-strips — this whole if/else collapses to let prefix = object_path;. It's only correct today because store.list matches on path-segment boundaries anyway.
| async fn delete_stream(&self, paths: BoxStream<'static, String>) -> Result<()> { | ||
| paths | ||
| .map(Ok) | ||
| .try_for_each_concurrent(16, |path| async move { |
There was a problem hiding this comment.
Same batching point as delete_prefix — this issues one DeleteObject per path capped at 16 in flight, where DeleteObjects takes 1,000 per request. I'd route this through store.delete_stream(paths) too; if we keep the concurrent form, pull the 16 out into a named const.
| .await | ||
| .map_err(from_object_store_error)?; | ||
| Ok(FileMetadata { | ||
| size: meta.size as u64, |
There was a problem hiding this comment.
ObjectMeta::size is already u64 in object_store 0.13, so this cast is a no-op that trips clippy::useless_conversion. Just size: meta.size,.
|
@laskoviymishka thanks for follow up, perhaps it's my fault I didnt properly review the CITY's code. Now that u have pointed out these issues I suspect there must be more of em, So I would like to take about 2 days minimum to get everything resolved-review-amended and also look for unknown hiccups. This means more research Currently its night here so I will get to reading ur followup thoroughly tommarrow 😊 Again, thanks for the detailed follow up |
|
@Sruhvx-jpg no rush, keep your time here. |
|
@laskoviymishka addressed all the feedback and added several hardening improvements:
All checks, clippy lints, and CI tests are green. Ready for another pass whenever you have time! |
37180ce to
6457dbd
Compare
|
Also on the struct wrappers ( If u like such style, we can maybe expand its usage as I have never seen a rust code with wrapper structs, which is understandable as is an repetitive job - but with advent of ai its much easier |
…blic-api baseline
97bcddf to
ed8a391
Compare
This comment was marked as off-topic.
This comment was marked as off-topic.
|
@laskoviymishka Hey! All CI's Green - "Make no mistake" worked this time 😄 |
Inherited from main (tracked in apache#3222); same bump as apache#3165. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
laskoviymishka
left a comment
There was a problem hiding this comment.
Almost there, thanks for turning this around fast.
The one thing that blocks merge is the SSE-KMS default-key path. Forwarding the key landed, but when s3.sse.type=kms is set with no explicit key we pass "" into with_sse_kms_encryption, which emits an empty key-id header on every PUT and S3 rejects it with InvalidArgument. The store still builds, so writes fail silently — and "use the bucket's default CMK" is the most common KMS setup. Java and opendal send aws:kms with no key-id header there; I'd match that.
Everything else I asked for last round is in: SSE config is forwarded, parse_s3_url is rebuilt on Url fields (uppercase schemes work now), store_cache/config are private, the Drop abort is present, and the MinIO tests exercise real reads and writes.
Two of those aren't fully closed yet, though — both smaller than the blocker:
- the percent-encoded bucket half of the parse fix is still open (
s3://my%2Dbucket/...gives a bucket no S3 store has; the current test pins that behaviour rather than fixing it) - the
Dropis in, butclose()consumes the writer beforefinish(), so on a failed finish there's nothing left to abort — the exact case it's for — and the multipartwriter()path still has no test
Fix the KMS path and I'm happy to take another pass. I'd like the other two closed here too since the follow-up backends build on this, but they're mechanical from where you are now.
| } | ||
| } | ||
|
|
||
| let bucket = url.host_str().ok_or_else(|| { |
There was a problem hiding this comment.
The Url rework fixed the uppercase-scheme case from last round, nice. The percent-encoded bucket half is still open though: host_str() returns the host still percent-encoded, so s3://my%2Dbucket/... yields bucket my%2Dbucket, which we hand straight to with_bucket_name and also use as the cache key — no real bucket has that name, and s3://my-bucket vs s3://my%2Dbucket split into two cache entries for the same bucket.
I'd decode the host, or reject any host containing % with DataInvalid. The current test asserts the encoded form, so it's pinning the bug rather than the fix.
| .writer | ||
| .take() | ||
| .ok_or_else(|| Error::new(ErrorKind::Unexpected, "Writer has already been closed"))?; | ||
| writer.finish().await.map_err(from_object_store_error)?; |
There was a problem hiding this comment.
The Drop-abort from last round is in, but it can't fire in the case it's for.
close() takes the writer out before finish().await, so if finish() errors the WriteMultipart is already consumed by value — by the time Drop runs, self.writer is None and the guard skips the abort. That's exactly the transient-error path where parts have already been flushed to S3 and now leak until a lifecycle rule expires them.
Holding the lower-level Box<dyn MultipartUpload> instead of WriteMultipart lets us abort() on a failed complete() and again in Drop. wdyt?
There was a problem hiding this comment.
but holding box<dyn multipartupload> also means we have to write our own WriteMultipart???
| }; | ||
|
|
||
| let mut list_stream = target.store.list(Some(&prefix)); | ||
| while let Some(entry) = list_stream.next().await { |
There was a problem hiding this comment.
This lists the prefix then deletes one object at a time, each awaited before the next — 10k objects is 10k sequential round-trips, and snapshot expiry hits this with large numbers of manifests. ObjectStoreExt::delete_stream (already imported) maps to S3 DeleteObjects at up to 1000 keys per request.
Piping the list stream into it also lets us drop the trailing-slash branch just above, which is a no-op anyway since ObjectStorePath normalizes trailing slashes. Something like store.list(Some(&prefix)).map_ok(|m| m.location)... fed into delete_stream(...).
There was a problem hiding this comment.
Thanks for this one suggestion, got to learn something new
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn test_file_io_s3_output() { |
There was a problem hiding this comment.
The MinIO tests landed — that closes the "nothing exercises a real read/write" ask. They all go through write(bytes) → put() though; the writer() → WriteMultipart path has no coverage, and that's the primary path for streaming Parquet/Avro data files (and where the Drop/abort issues above live).
A single test that writes past the multipart threshold, closes, and reads back — plus one that drops a writer without closing — would cover both the multipart lifecycle and ask #4 end-to-end.
| } | ||
| "AES256" => { | ||
| builder = builder.with_config( | ||
| AmazonS3ConfigKey::from_str("aws_server_side_encryption").map_err(|e| { |
There was a problem hiding this comment.
This branch reaches into object_store internals by string, which gives us an Unexpected error path that can never fire and a silent dependency on a private constant across a crate boundary. There's a typed form:
builder = builder.with_config(
AmazonS3ConfigKey::Encryption(S3EncryptionConfigKey::ServerSideEncryption),
"AES256",
);Worth a server_side_encryption = "AES256" unit test too, since nothing exercises this branch today.
| } | ||
|
|
||
| if let Some(ref custom_key) = config.server_side_encryption_customer_key { | ||
| builder = builder.with_ssec_encryption(custom_key); |
There was a problem hiding this comment.
server_side_encryption_customer_key_md5 gets populated by TryFrom when s3.sse.md5 is set, but we only forward custom_key here — the MD5 is dropped. Some S3-compatible stores validate the supplied MD5 and reject SSE-C ops without it. Can we check whether with_ssec_encryption computes it for us, and forward it (or log) if not?
| impl Drop for ObjectStoreWriter { | ||
| fn drop(&mut self) { | ||
| if let Some(writer) = self.writer.take() | ||
| && let Ok(handle) = tokio::runtime::Handle::try_current() |
There was a problem hiding this comment.
One more thing on this Drop: if it runs outside a Tokio context (try_current() fails on a sync drop or during shutdown) the abort is silently skipped. I'd at minimum tracing::warn! in that branch so an orphaned upload isn't invisible, or document that abort-on-drop only holds from an async context.
…efix, and multipart tests
|
@laskoviymishka Addressed all feedback from the latest review:
All checks, clippy lints, and integration tests are green. Ready for another pass! |
Which issue does this PR close?
What changes are included in this PR?
Implement
ObjectStoreStorage::S3backed by Apache Arrow'sobject_storecrate. Originally drafted by @CTTY in #2257 and revived onto current main:object_store0.13 to workspace dependencies (aligned with DataFusion).s3://,s3a://, ands3n://URL schemes with empty bucket validation.WriteMultipart::put(bs)instead of slice copying.delete_streamusingtry_for_each_concurrent.Are these changes tested?
Yes, all 12 unit tests covering S3 URL parsing, empty bucket checks, store cache reuse, and
FileIO/StorageFactoryserialization roundtrips passing (cargo test -p iceberg-storage-object_store).